package pl.qsoft.db.storedProc.engine;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.naming.InitialContext;
import javax.sql.DataSource;

import pl.qsoft.db.annotation.StoredProcedure;
import pl.qsoft.db.annotation.StoredProcedureDefinition;
import pl.qsoft.db.annotation.StoredProcedureParam;
import pl.qsoft.db.annotation.StoredProcedure.CachePolicy;
import pl.qsoft.db.exception.StoredProcedureDefinitionException;
import pl.qsoft.db.exception.StoredProcedureInvocationException;
import pl.qsoft.db.storedProc.cache.CacheManager;
import pl.qsoft.db.storedProc.cache.CacheUtils;
import pl.qsoft.db.storedProc.intercept.InvocationInterceptor;
import pl.qsoft.db.storedProc.intercept.ProcedureInvocationContext;
import pl.qsoft.db.storedProc.statement.ResultTransformer;
import pl.qsoft.db.storedProc.statement.StatementBuilder;
import pl.qsoft.db.storedProc.statement.StatementBuilderFactory;
import pl.qsoft.db.storedProc.statement.StoredProcedureStatement;

/**
 * Basic implementation of a InvocationEngine interface. It is responsible for automation of the whole process of interpreting stored procedure params, creating connection, etc.
 * @author Wojciech Kudla
 * @see ProcedureInvocationEngine
 */
public class GenericInvocationEngine implements ProcedureInvocationEngine {


	private DataSource datasource;
	

	public void setDatasource(DataSource datasource) {
		this.datasource = datasource;
	}

	public GenericInvocationEngine() {
	}
	
	/**
	 * Main method that is accesible for the component client
	 * @param params Stored procedure parameter values 
	 */
	@SuppressWarnings("unchecked")
	public Object invoke(Object... params) {
		
		// Get method from which this.invoke() is being called
		Method method = getInvokingMethod(params);
		StoredProcedure proc = method.getAnnotation(StoredProcedure.class);
		
		// Handle exceptional situations
		if (proc == null) 
			throw new StoredProcedureDefinitionException("The method " + method.getName() + " is not annotated with @StoredProcedure annotation");
		StoredProcedureDefinition procDef = (StoredProcedureDefinition) proc.procedureDefinition().getAnnotation(StoredProcedureDefinition.class);
		if (procDef == null) 
			throw new StoredProcedureDefinitionException("The class " + proc.procedureDefinition().getCanonicalName() + 
					" given as stored procedure definition is not annotated with @StoredProcedureDefinition annotation");
		StoredProcedureParam[] paramDefs = collectStoredProcedureParams(proc);
		if (paramDefs == null || paramDefs.length == 0)
			throw new StoredProcedureDefinitionException("The class " + proc.procedureDefinition().getCanonicalName() + 
					" does not define any properties annotated with @StoredProcedureParam");
		
		// [paramName/index -> value] map
		Map<Object, Object> valuesByNames = getParamNameToValueMap(method, params);
		// [paramDef -> value] map
		Map<StoredProcedureParam, Object> valuesByParamDefs = getParamDefToValueMap(valuesByNames, paramDefs);
		
		// handling interceptor
		InvocationInterceptor interceptor = getInvocationInterceptor(proc);
		ProcedureInvocationContext invocationContext = new ProcedureInvocationContext(procDef, proc, valuesByParamDefs);
		interceptor.beforeInvoke(invocationContext);
		
		Object result = null;
		
		// handling result cache 
		if (!proc.cachePolicy().equals(CachePolicy.DISABLED)) {
			result = CacheManager.getResultFromCache(procDef, valuesByNames);
			if (result != null) {
				interceptor.afterInvoke(invocationContext);
				if (proc.cachePolicy().equals(CachePolicy.CLONED)) result = CacheUtils.deepCopy(result);
				return result;
			}
		}
		
		// Statement preparation and execution
		StatementBuilder stmtBuilder = StatementBuilderFactory.getStatementBuilder(procDef.dataBaseVendor());
		Connection conn = null;
		CallableStatement stmt = null;
		try {
			conn = getConnection(procDef);
			String stmtString = buildStatementString(procDef, paramDefs.length);
			stmt = new StoredProcedureStatement(conn.prepareCall(stmtString));
			stmt = stmtBuilder.buildStatement(stmt, procDef, paramDefs, valuesByNames);
			
			if (StoredProcedureDefinition.ExecutionType.PROCEDURE.equals(procDef.executionType()))
				stmt.execute(); 
			else if (StoredProcedureDefinition.ExecutionType.FUNCTION.equals(procDef.executionType()))
				stmt.executeQuery();
			
			// handling interceptor and result transformation
			interceptor.afterInvoke(invocationContext);
			result = tranformResult(stmt, procDef, valuesByNames);
		} catch (Exception e) {
			throw new StoredProcedureInvocationException("Exception handling stored procedure invocation", e);
		} finally {
			releaseResources((StoredProcedureStatement) stmt, conn);
		}
		
		// putting results to cache
		if (!proc.cachePolicy().equals(CachePolicy.DISABLED))
			CacheManager.putResultToCache(procDef, valuesByNames, result);
		
		return result;
	}
	
	
	/**
	 * Releases all reserved resources - opened result sets, statements and connections
	 * @param stmt Stored procedure statement (containing result sets) to be closed
	 * @param conn Database connection to be closed 
	 */
	protected void releaseResources(StoredProcedureStatement stmt, Connection conn) {
		try {
			Set<ResultSet> results = stmt.getOpenedResultSets();
			for (ResultSet rs : results) if (rs != null) rs.close();
			stmt.close(); conn.close();
		} catch (Exception e) {
			throw new StoredProcedureInvocationException("Problem releasing resources", e);
		}		
	}
	
	
	/**
	 * Transforms executed statement containing values to the desired java objects 
	 * @param stmt Statement that has been executed
	 * @param procDef Stored procedure definition
	 * @param values Stored procedure parameter names/indexes with values
	 * @return Transformed java object
	 */
	@SuppressWarnings("unchecked")
	protected Object tranformResult(CallableStatement stmt, StoredProcedureDefinition procDef, Map values) {
		Object result = null;
		try {
			ResultTransformer transformer = (ResultTransformer) CacheManager.getClassFromCache(procDef.resultTransformer()); 
			if (transformer == null) {
				transformer = (ResultTransformer) procDef.resultTransformer().newInstance();
				CacheManager.putClassToCache(transformer);
			}
			result = transformer.transform(stmt, procDef, values);
		} catch (Exception e) {
			throw new StoredProcedureInvocationException("Exception transforming result", e);
		} 
		return result;
	}
	
	
	/**
	 * Obtains defined InvocationInterceptor
	 * @param proc Stored procedure that defines the interceptor
	 * @return Invocation interceptor
	 * @see InvocationInterceptor
	 */
	protected InvocationInterceptor getInvocationInterceptor(StoredProcedure proc) {
		InvocationInterceptor interceptor = (InvocationInterceptor) CacheManager.getClassFromCache(proc.invocationInterceptor());
		if (interceptor == null) {
			try {
				interceptor = (InvocationInterceptor) proc.invocationInterceptor().newInstance();
				CacheManager.putClassToCache(interceptor);
			} catch (Exception e) {
				throw new StoredProcedureInvocationException("Exception obtaining interceptor for stored procedure", e);
			}
		}
		return interceptor;
	}
	
	
	/**
	 * Creates a map of name/index -> value pairs that binds each parameter value with its identity
	 * @param method Calling method annotated with the <code>@StoredProcedure</code> annotation
	 * @param values Values passed to the calling method
	 * @return Map of name/index -> value pairs
	 */
	private Map<Object, Object> getParamNameToValueMap(Method method, Object... values) {
		Map<Object, Object> result = new HashMap<Object, Object>();
		Annotation[][] annotations = method.getParameterAnnotations();
		for (int p = 0; p < annotations.length; p++) {
			for (int a = 0; a < annotations[p].length; a++) {
				StoredProcedureParam param = (StoredProcedureParam) annotations[p][a];
				if (param != null) {
					if (!param.paramName().equals("")) result.put(param.paramName(), values[p]);
					if (param.paramNumber() > -1 ) result.put(param.paramNumber(), values[p]);
				}
			}
		}
		return result;
	}
	
	
	/**
	 * Creates a map of <code>@StoredProcedureParam</code> -> value pairs that binds each parameter value with its param
	 * @param method Calling method annotated with the <code>@StoredProcedure</code> annotation
	 * @param values Values passed to the calling method
	 * @return Map of <code>@StoredProcedureParam</code> -> value pairs
	 */
	private Map<StoredProcedureParam, Object> getParamDefToValueMap(Map<Object, Object> values, StoredProcedureParam[] paramList) {
		Map<StoredProcedureParam, Object> result = new HashMap<StoredProcedureParam, Object>();
		
		for (int p = 0; p < paramList.length; p++) {
			if (paramList[p].paramKind() != StoredProcedureParam.ParamKind.OUT) {
				if (!paramList[p].paramName().equals("")) result.put(paramList[p], values.get(paramList[p].paramName()));
				if (paramList[p].paramNumber() > -1 ) result.put(paramList[p], values.get(paramList[p].paramNumber()));
			}
		}
		return result;
	}

	
	/**
	 * Collects stored procedure parameter annotation from the class defining the stored procedure itself
	 * @param proc Stored procedure
	 * @return An array of stored procedure parameter descriptions
	 */
	private StoredProcedureParam[] collectStoredProcedureParams(StoredProcedure proc) {
		Field[] fields = proc.procedureDefinition().getDeclaredFields();
		List<StoredProcedureParam> paramList = new ArrayList<StoredProcedureParam>();
		for (int i = 0; i < fields.length; i++) {
			StoredProcedureParam param = fields[i].getAnnotation(StoredProcedureParam.class);
			if (param != null) paramList.add(param);
		}
		StoredProcedureParam[] result = new StoredProcedureParam[paramList.size()];
		return paramList.toArray(result);
	}


	/**
	 * Builds statement SQL string
	 * @param proc Stored procedure definition
	 * @param length Number of parameters
	 * @return SQL statement string
	 */
	protected String buildStatementString(StoredProcedureDefinition proc, int length) {
		String result = null;
		if (StoredProcedureDefinition.ExecutionType.PROCEDURE.equals(proc.executionType())) {
			result = buildStatmentForProcedure(proc, length);
		} else if (StoredProcedureDefinition.ExecutionType.FUNCTION.equals(proc.executionType())){
			result = buildStatmentForFunction(proc, length);
		}
		return result;
	}
	
	private String buildStatmentForProcedure(StoredProcedureDefinition proc, int length) {
		String result = "{call " + proc.procedureName() + "(";
		for (int i = 0; i < length; i++) result += i != length - 1 ? "?," : "?)}";
		return result;
	}
	
	private String buildStatmentForFunction(StoredProcedureDefinition proc, int length) {
		String result = "select " + proc.procedureName() + "(";
		for (int i = 0; i < length; i++) result += i != length - 1 ? "?," : "?) as result from dual";
		return result;
	}
	
	//TODO: move this logic to a separate class with defined interface
	/**
	 * Does a connection lookup in the JNDI
	 * @param proc Stored procedure definition
	 * @return Connection object for the given datasource path
	 */
	protected Connection getConnection(StoredProcedureDefinition proc) {
		Connection conn = null;
		try {
			if (this.datasource != null) conn = this.datasource.getConnection();
			else {
				InitialContext ctx = new InitialContext();
				DataSource ds = (DataSource) ctx.lookup(proc.dataSource());
				conn = ds.getConnection();
			}
		} catch (Exception e) {
			throw new StoredProcedureDefinitionException("Cannot obtain connection from datasource defined in dataSource property", e);
		}
		return conn;
	}
	
	
	/**
	 * Retrieves calling method object for further processing (obtaining additional information from its annotation and annotation of its parameters)
	 * @param params Method's input parameter values
	 * @return Method object
	 */
	@SuppressWarnings("unchecked")
	protected Method getInvokingMethod(Object... params) {
		StackTraceElement[] elements = Thread.currentThread().getStackTrace();
		for(StackTraceElement element : elements) {
			try {
				String methodName = element.getMethodName();
				Class clazz = Class.forName(element.getClassName());
				Method[] methods = clazz.getMethods(); 
				for(Method m : methods) {
					if (m.getName().equals(methodName) && m.isAnnotationPresent(StoredProcedure.class)) {
						return m;
					}
				}
			} catch (ClassNotFoundException e) {
				throw new StoredProcedureInvocationException("Exception finding calling class", e);
			}
		}
		throw new IllegalStateException("Unable to find @StoredProcedure annotated method. Possible causes are: (1) no annotation at all, (2) method is not public");	
	}
	
		
}
